查看原文
其他

Android 自定义控件 | 弹幕的两种实现及性能对比

技术最TOP 2022-08-26

作者:唐子玄, 链接:https://juejin.cn/post/7004603099113340936

弹幕有多种实现方式,该系列介绍其中的两种,并对比它们的性能。

引子

实现如上图所示的弹幕,第一个想到的方案是 “动画”,即自定义容器控件,将子控件布局在容器控件右边的外侧,然后为每个子控件启动一个从右向左的动画。

每当有一个新弹幕,弹幕容器控件就应该执行如下操作:

  • 生成一个新的子控件

  • 为子控件绑定数据

  • 测量子控件

  • 将子控件添加到容器控件

  • 布局子控件

  • 开启子控件动画

// 自定义弹幕容器控件
class LaneView 
    @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
    :ViewGroup(context, attrs, defStyleAttr) {
    // 存放弹幕数据的列表
    private var datas = emptyList<Any>()
    // 展示一条弹幕
    fun show(data: Any) {
        post {
            // 1.生成新子控件
            val child = obtain()
            // 2.为子控件绑定数据
            bindView(data, child)
            // 3.测量子控件
            val width = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
            val height = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
            child.measure(width, height)
            // 4.将子控件添加到容器控件
            addView(child)
            // 5.布局子控件
            val left = measuredWidth
            val top = getRandomTop(child.measuredHeight)
            child.layout(left, top, left + child.measuredWidth, top + child.measuredHeight)
            // 6.开启子控件动画
            laneMap[top]?.add(child, data) ?: run {
                Lane(measuredWidth).also {
                    it.add(child, data)
                    laneMap[top] = it
                    it.showNext()
                }
            }
        }
    }
    
    // 展示多条弹幕
    fun show(datas: List<Any>) {
        this.datas = datas
        datas.forEach { show(it) }
    }
}

自定义弹幕容器控件LaneView公开了两个show()方法,用于触发弹幕的展示。然后就可以像这样使用弹幕控件:

val laneView = findViewById(R.id.laneView)
laneView.show(datas)

缓存弹幕

如果每一条弹幕都重新创建视图就容易发生内存抖动,优化方案是使用缓存池将离屏弹幕视图缓存以供新弹幕使用。

androidx.core.util包下有一个Pools类,其中定义了一个Pool接口及它的简单实现。利用它可以方便的实现缓存池:

// 池
public interface Pool<T> {
    // 从池中获取对象
    T acquire();
    // 释放对象
    boolean release(@NonNull T instance);
}

Pool接口中定义池的两个必要操作,即获取对象和释放对象。

SimplePool是对Pool的一个实现:

public static class SimplePool<T> implements Pool<T> {
    // 池对象容器
    private final Object[] mPool;
    // 池大小
    private int mPoolSize;

    public SimplePool(int maxPoolSize) {
        if (maxPoolSize <= 0) {
            throw new IllegalArgumentException("The max pool size must be > 0");
        }
        // 构造池对象容器
        mPool = new Object[maxPoolSize];
    }

    // 从池容器中获取对象
    public T acquire() {
        if (mPoolSize > 0) {
            // 总是从池容器末尾读取对象
            final int lastPooledIndex = mPoolSize - 1;
            T instance = (T) mPool[lastPooledIndex];
            mPool[lastPooledIndex] = null;
            mPoolSize--;
            return instance;
        }
        return null;
    }

    // 释放对象并存入池
    @Override
    public boolean release(@NonNull T instance) {
        if (isInPool(instance)) {
            throw new IllegalStateException("Already in the pool!");
        }
        // 总是将对象存到池尾
        if (mPoolSize < mPool.length) {
            mPool[mPoolSize] = instance;
            mPoolSize++;
            return true;
        }
        return false;
    }

    // 判断对象是否在池中
    private boolean isInPool(@NonNull T instance) {
        // 遍历池对象
        for (int i = 0; i < mPoolSize; i++) {
            if (mPool[i] == instance) {
                return true;
            }
        }
        return false;
    }
}

有了SimplePool的帮助实现弹幕缓存池就轻而易举了:

class LaneView 
    @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
    :ViewGroup(context, attrs, defStyleAttr) {
    // 弹幕池
    private lateinit var pool: Pools.SimplePool<View>
    // 构建弹幕视图的 lambda
    lateinit var createView: () -> View
    // 从池中获取弹幕,若失败则重新构建弹幕视图
    private fun obtain(): View = pool.acquire() ?: createView()
    // 回收离屏弹幕
    private fun recycle(view: View) {
        view.detach()
        pool.release(view)
    }
}

obtain()尝试从弹幕池中获取弹幕视图,若失败则重新创建。recycle()用于在弹幕视图动画结束后进行回收并存入弹幕池。

自定义弹幕布局 & 绑定数据

一条弹幕布局中有哪些控件?每个控件如何展示数据?

这是两个随着业务变化而变的点,遂把它们抽象成两个“策略”,其实现由外部注入。

class LaneView 
    @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
    :ViewGroup(context, attrs, defStyleAttr) {
    // 构建弹幕视图的 lambda
    lateinit var createView: () -> View
    // 绑定弹幕数据的 lambda
    lateinit var bindView: (Any, View) -> Unit
}

lambda 来表达策略要比用 interface 来的简洁,然后就可以像这样从外部将策略注入:

val laneView = findViewById(R.id.laneView)
laneView.apply {
    // 注入弹幕视图的构建策略
    createView =  ConstraintLayout {
        layout_width = wrap_content
        layout_height = 27
        padding_end = 8
        padding_start = 3
        padding_top = 3
        padding_bottom = 3
        shape = shape {
            corner_radius = 17
            solid_color = "#660C0B1C"
        }
        // 圆形头像
        StrokeImageView {
            layout_id = "ivLane"
            layout_width = 21
            layout_height = 21
            scaleType = scale_fit_xy
            start_toStartOf = parent_id
            center_vertical = true
            roundedAsCircle = true
        }
        // 弹幕文字
        TextView {
            layout_id = "tvLane"
            layout_width = wrap_content
            layout_height = wrap_content
            textSize = 11f
            textColor = "#ffffff"
            center_vertical = true
            start_toEndOf = "ivLane"
            margin_start = 6
        }
    }
    // 注入弹幕视图数据绑定策略
    bindView = { data, view ->
        view.find<TextView>("tvLane")?.apply {
            text = data?.text
            maxEms = 15
            isSingleLine = true
            ellipsize = ellipsize_end
        }
        view.find<StrokeImageView>("ivLane")?.let {
            Glide.with(it.context).load(data.url).into(it)
        }
    }
}

上述代码构建了一个用于展示圆形头像及文字的弹幕视图,和本篇开头演示的 GIF 效果一致。

其中运用了 Kotlin DSL 动态地声明式地构建了布局,详细介绍可以点击 Android性能优化 | 把构建布局用时缩短 20 倍(下) - 掘金 (juejin.cn)

测量 & 布局

自定义容器控件必须做的两件事是测量和布局子控件,即确定子控件的尺寸和位置。

测量的落脚点是“mMeasuredWidth和mMeasuredHeight被赋值”,通过调用View.measure()实现:

public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
    ...
    setMeasuredDimensionRaw()
    ...
}

private void setMeasuredDimensionRaw(int measuredWidth, int measuredHeight) {
    mMeasuredWidth = measuredWidth;
    mMeasuredHeight = measuredHeight;
    mPrivateFlags |= PFLAG_MEASURED_DIMENSION_SET;
}

布局的落脚点是“mLeftmTopmRightmBottom被赋值”,通过View.layout()实现:

public void layout(int l, int t, int r, int b) {
    ...
    setFrame()
    ...
}

protected boolean setFrame(int left, int top, int right, int bottom) {
    ...
    mLeft = left;
    mTop = top;
    mRight = right;
    mBottom = bottom;
    ...
}

关于 View 绘制流程的详细介绍可以点击Android自定义控件 | View绘制原理(画多大?)

弹幕控件的show()方法中就通过调用measure()layout()来实现测量及布局子控件:

//自定义弹幕控件
class LaneView 
    @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
    :ViewGroup(context, attrs, defStyleAttr) {

    // 展示一条弹幕
    fun show(data: Any) {
        post {
            val child = obtain()
            bindView(data, child)
            // 3.测量子控件
            val width = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
            val height = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
            child.measure(width, height)
            // 4.将子控件添加到容器控件
            addView(child)
            // 5.布局子控件
            val left = measuredWidth // 子控件的左侧位于弹幕控件的右侧
            val top = getRandomTop(child.measuredHeight)
            child.layout(left, top, left + child.measuredWidth, top + child.measuredHeight)
            ...
        }
    }
}

弹幕被添加到容器控件的初始位置是“容器控件最右侧的外边”,即处于一个不可见的外侧位置,实现方式是将子控件的左侧置于容器控件的右侧即可:

val left = measuredWidth
复制代码
其中measuredWidth表示容器控件的测量宽度。

getRandomTop()用于让每一个弹幕随机的分布在不同的“泳道”中,位于同一行的弹幕称为同一泳道。(开篇 GIF 包含了 4 个泳道):

class LaneView 
    @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
    :ViewGroup(context, attrs, defStyleAttr) {
    // 泳道垂直间距
    var verticalGap: Int = 5
        set(value) {
            field = value.dp
        }

    private fun getRandomTop(commentHeight: Int)Int {
        // 计算布局泳道的可用高度
        val lanesHeight = measuredHeight - paddingTop - paddingBottom
        // 计算可用高度中最多能布局几条泳道
        val lanesCapacity = (lanesHeight + verticalGap) / (commentHeight + verticalGap)
        // 计算可用高度布局完所有泳道后剩余空间
        val extraPadding = lanesHeight - commentHeight * lanesCapacity - verticalGap * (lanesCapacity - 1)
        // 计算第一条泳道相对于容器控件的 mTop 值
        val firstLaneTop = paddingTop + extraPadding / 2
        // 计算泳道垂直方向的随机偏移量
        val randomOffset = (0 until lanesCapacity).random() * (commentHeight + verticalGap)
        return firstLaneTop + randomOffset
    }
}

做动画

每一条泳道都是一个队列,存放着等待做动画的弹幕视图。

// 泳道
class Lane(var laneWidth: Int) {
    // 弹幕视图队列
    private var viewQueue = LinkedList<View>()
    private var currentView: View? = null
    // 用于限制泳道内弹幕间距的布尔值
    private var blockShow = false
    // 弹幕布局监听器
    private val onLayoutChangeListener =
        OnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
            // 只有当前一个弹幕滚动得足够远,才开启下一个弹幕的动画
            if (laneWidth - left > v.measuredWidth + horizontalGap) {
                blockShow = false
                showNext()
            }
        }
    // 开始该泳道中下一个弹幕的滚动
    fun showNext() {
        // 还未到展示下一个弹幕,则直接返回
        if (blockShow) return
        currentView?.removeOnLayoutChangeListener(onLayoutChangeListener)
        // 从泳道队列中取出弹幕视图
        currentView = viewQueue.poll()
        currentView?.let { view ->
            // 为弹幕视图添加布局变化监听器
            view.addOnLayoutChangeListener(onLayoutChangeListener)
            // 计算每个弹幕的动画时间
            val distance = laneWidth + view.measuredWidth
            val speed = laneWidth.toFloat() / 4000
            val duration = (distance / speed).toLong()
            // 构造 ValueAnimator
            val valueAnimator = ValueAnimator.ofFloat(1.0f).apply {
                setDuration(duration)
                interpolator = LinearInterpolator()
                addUpdateListener {
                    val value = it.animatedFraction
                    val left = (laneWidth - value * (laneWidth + view.measuredWidth)).toInt()
                    // 通过重新布局来实现弹幕视图的滚动
                    view.layout(left, view.top, left + view.measuredWidth, view.top + view.measuredHeight)
                }
                addListener {
                    // 动画结束时回收弹幕视图
                    onEnd = { recycle(view) }
                }
            }
            // 弹幕视图滚动开始
            valueAnimator.start()
            blockShow = true
        }
    }
    
    // 添加弹幕视图
    fun add(view: View, data: Any) {
        viewQueue.addLast(view)
        showNext()
    }
}

Lane是泳道的抽象,它用LinkedList作为存放弹幕视图的队列。

Lane.showNext()从队列中取出弹幕视图,并为它构建从右到左的位移动画,通过 ValueAnimator 生成一组[0,1]值用于表示动画0-100%的进度,由此计算出动画过程中弹幕视图的left值,最终通过调用view.layout()实现弹幕的平移。

为了让同一条泳道的弹幕不发生重叠,只有当前一条弹幕滚动足够长的距离后,才能开启下一个弹幕的动画。所以为弹幕视图设置了布局变化监听器,当弹幕视图完全平移出屏幕并且又滚动了水平间距horizontalGap后才开启下一个弹幕视图的动画。

响应点击事件

为了响应每个弹幕的点击事件,需要拦截弹幕容器控件的触摸事件:

class LaneView 
    @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
    :ViewGroup(context, attrs, defStyleAttr) {
    // 记录所有泳道的map结构
    private var laneMap = ArrayMap<Int, Lane>()
    // 弹幕点击监听器
    var onItemClick: ((View, Any) -> Unit)? = null
    // 手势监听器
    private val gestureDetector = GestureDetector(context, object : GestureDetector.OnGestureListener {
        override fun onShowPress(e: MotionEvent?) {
        }

        override fun onSingleTapUp(e: MotionEvent?)Boolean {
            // 将单击事件传递给监听器
            e?.let {
                findDataUnder(it.x, it.y)?.let { pair ->
                    // 执行单击事件响应逻辑
                    onItemClick?.invoke(pair.first, pair.second)
                }
            }
            return false
        }

        override fun onDown(e: MotionEvent?)Boolean {
            return false
        }

        override fun onFling(e1: MotionEvent?,e2: MotionEvent?,velocityX: Float,velocityY: Float)Boolean {
            return false
        }

        override fun onScroll(e1: MotionEvent?,e2: MotionEvent?,distanceX: Float,distanceY: Float)Boolean {
            return false
        }

        override fun onLongPress(e: MotionEvent?) {
        }
    })

    override fun dispatchTouchEvent(ev: MotionEvent?)Boolean {
        // 将触摸事件分发给手势监听器
        gestureDetector.onTouchEvent(ev)
        return super.dispatchTouchEvent(ev)
    }
}

dispatchTouchEvent()中将触摸事件传递给手势监听器,它将触摸事件解析成单击事件,并通过onSingleTapUp()回调出来。

onSingleTapUp()中通过findDataUnder()找到触摸事件对应的弹幕视图:

private fun findDataUnder(x: Float, y: Float): Pair<View, Any>? {
    var pair: Pair<View, Any>? = null
    // 遍历所有泳道
    laneMap.values.forEach { lane ->
        // 遍历泳道中展示的弹幕视图
        lane.forEachView { view, data ->
            // 获取弹幕与容器控件的相对位置
            view.getRelativeRectTo(this@LaneView).also { rect ->
                if (rect.contains(x.toInt(), y.toInt())) {
                    pair = view to data
                }
            }
        }
    }
    return pair
}

其中getRelativeRectTo()用计算于某个 View 相对于另一个 View 的位置。

private fun View.getRelativeRectTo(otherView: View): Rect {
    // 将子视图和父视图置于同一个全局坐标系,并获取他们的矩形区域
    val parentRect = Rect().also { otherView.getGlobalVisibleRect(it) }
    val childRect = Rect().also { getGlobalVisibleRect(it) }
    // 获取父子视图矩形区域的相对位置
    return childRect.relativeTo(parentRect)
}

private fun Rect.relativeTo(otherRect: Rect): Rect {
    val relativeLeft = left - otherRect.left
    val relativeTop = top - otherRect.top
    val relativeRight = relativeLeft + right - left
    val relativeBottom = relativeTop + bottom - top
    return Rect(relativeLeft, relativeTop, relativeRight, relativeBottom)
}

性能

用这套方案实现弹幕的性能有待提高。

打开 GPU 呈现模式柱状图:

弹幕作为列表的一个部分,先将其移出屏幕,当再次进入屏幕时,列表的滚动会顿一下。从柱状图中可以看出,绿色的柱体很高,这表示measure+layoutanimation的耗时过长。

原因在于fun show(datas: List<Any>),若服务器返回 100 条弹幕数据,则这一瞬间就有 100 个弹幕视图被构建并成为弹幕容器控件的子视图,它们都堆积在屏幕右边的外侧。

下一篇将分享另一种性能更加优越的方案~~

Talk is cheap, show me the code 完整代码可以点击这里:https://github.com/wisdomtl/taylorCode


---END---


推荐阅读:
在Flutter中实现酷炫的文本动画(含代码示例)
100 行写一个 Compose 版华容道
Jetpack新成员SplashScreen:为全新的应用启动效果赋能!
拿去吧你!用Compose 打造裸眼 3D 效果!
Android 实现抖音传送带特效!
看了这篇文章,终于搞懂了 Android 存储!
在Flutter中实现酷炫的文本动画(含代码示例)

更文不易,点个“在看”支持一下👇

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存